package packaging

import (
	"bytes"
	"crypto/tls"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"text/template"

	"github.com/Masterminds/semver"
	"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
	"github.com/fleetdm/fleet/v4/orbit/pkg/update"
	"github.com/fleetdm/fleet/v4/pkg/secure"
	"github.com/goreleaser/nfpm/v2"
	"github.com/goreleaser/nfpm/v2/arch"
	"github.com/goreleaser/nfpm/v2/files"
	"github.com/goreleaser/nfpm/v2/rpm"
	"github.com/rs/zerolog/log"
)

// Reusable snippet to conditionally wait to restart orbit upon an in-band upgrade
// (orbit installing an update to itself). Without this, the maintainer script will
// terminate orbit along with the package installation, aborting the install and
// potentially leaving the host in an unreachable state.
const postInstallSafeRestart = `
  if test -z "${INSTALLER_PATH:-}"; then
    systemctl restart orbit.service 2>&1
  else
    echo "Detected in-band upgrade (orbit upgrading orbit). Delaying service"
    echo "restart to prevent orbit from being stopped mid-script."
    if command -v systemd-run >/dev/null 2>&1; then
      systemd-run --on-active=60 --working-directory=/ systemctl restart --no-block orbit.service
    else
      echo "...nevermind, systemd-run not available, exiting postinst"
      echo "without restarting."
    fi
  fi
`

func buildNFPM(opt Options, pkger nfpm.Packager) (string, error) {
	// Initialize directories
	tmpDir, err := initializeTempDir()
	if err != nil {
		return "", err
	}
	defer os.RemoveAll(tmpDir)

	rootDir := filepath.Join(tmpDir, "root")
	if err := secure.MkdirAll(rootDir, constant.DefaultDirMode); err != nil {
		return "", fmt.Errorf("create root dir: %w", err)
	}
	orbitRoot := filepath.Join(rootDir, "opt", "orbit")
	if err := secure.MkdirAll(orbitRoot, constant.DefaultDirMode); err != nil {
		return "", fmt.Errorf("create orbit dir: %w", err)
	}

	if opt.Architecture != ArchAmd64 && opt.Architecture != ArchArm64 {
		return "", fmt.Errorf("Invalid architecture: %s", opt.Architecture)
	}

	// Initialize autoupdate metadata
	updateOpt := update.DefaultOptions

	updateOpt.RootDirectory = orbitRoot
	if opt.Architecture == ArchArm64 {
		updateOpt.Targets = update.LinuxArm64Targets
	} else {
		updateOpt.Targets = update.LinuxTargets
	}
	updateOpt.ServerCertificatePath = opt.UpdateTLSServerCertificate

	if opt.UpdateTLSClientCertificate != "" {
		updateClientCrt, err := tls.LoadX509KeyPair(opt.UpdateTLSClientCertificate, opt.UpdateTLSClientKey)
		if err != nil {
			return "", fmt.Errorf("error loading update client certificate and key: %w", err)
		}
		updateOpt.ClientCertificate = &updateClientCrt
	}

	if opt.Desktop {
		if opt.Architecture == ArchArm64 {
			updateOpt.Targets[constant.DesktopTUFTargetName] = update.DesktopLinuxArm64Target
		} else {
			updateOpt.Targets[constant.DesktopTUFTargetName] = update.DesktopLinuxTarget
		}
		// Override default channel with the provided value.
		updateOpt.Targets.SetTargetChannel(constant.DesktopTUFTargetName, opt.DesktopChannel)
	}

	// Override default channels with the provided values.
	updateOpt.Targets.SetTargetChannel(constant.OrbitTUFTargetName, opt.OrbitChannel)
	updateOpt.Targets.SetTargetChannel(constant.OsqueryTUFTargetName, opt.OsquerydChannel)

	updateOpt.ServerURL = opt.UpdateURL
	if opt.UpdateRoots != "" {
		updateOpt.RootKeys = opt.UpdateRoots
	}

	updatesData, err := InitializeUpdates(updateOpt)
	if err != nil {
		return "", fmt.Errorf("initialize updates: %w", err)
	}
	log.Debug().Stringer("data", updatesData).Msg("updates initialized")
	if opt.Version == "" {
		// We set the package version to orbit's latest version.
		opt.Version = updatesData.OrbitVersion
	}

	varLibSymlink := false
	if orbitSemVer, err := semver.NewVersion(updatesData.OrbitVersion); err == nil {
		if orbitSemVer.LessThan(semver.MustParse("0.0.11")) {
			varLibSymlink = true
		}
	}
	// If err != nil we assume non-legacy Orbit.

	// Write files

	_, isRPM := pkger.(*rpm.RPM)

	if err := writeSystemdUnit(opt, rootDir); err != nil {
		return "", fmt.Errorf("write systemd unit: %w", err)
	}

	if err := writeEnvFile(opt, rootDir); err != nil {
		return "", fmt.Errorf("write env file: %w", err)
	}

	if err := writeOsqueryFlagfile(opt, orbitRoot); err != nil {
		return "", fmt.Errorf("write flagfile: %w", err)
	}

	if err := writeOsqueryCertPEM(opt, orbitRoot); err != nil {
		return "", fmt.Errorf("write certs.pem: %w", err)
	}

	postInstallPath := filepath.Join(tmpDir, "postinstall.sh")
	if err := writePostInstall(opt, postInstallPath); err != nil {
		return "", fmt.Errorf("write postinstall script: %w", err)
	}
	preRemovePath := filepath.Join(tmpDir, "preremove.sh")
	if err := writePreRemove(pkger, preRemovePath); err != nil {
		return "", fmt.Errorf("write preremove script: %w", err)
	}
	postRemovePath := filepath.Join(tmpDir, "postremove.sh")
	if err := writePostRemove(postRemovePath); err != nil {
		return "", fmt.Errorf("write postremove script: %w", err)
	}
	var postTransPath string
	if isRPM {
		postTransPath = filepath.Join(tmpDir, "posttrans.sh")
		if err := writeRPMPostTrans(opt, postTransPath); err != nil {
			return "", fmt.Errorf("write RPM posttrans script: %w", err)
		}
	}

	if opt.FleetCertificate != "" {
		if err := writeFleetServerCertificate(opt, orbitRoot); err != nil {
			return "", fmt.Errorf("write fleet server certificate: %w", err)
		}
	}

	if opt.FleetTLSClientCertificate != "" {
		if err := writeFleetClientCertificate(opt, orbitRoot); err != nil {
			return "", fmt.Errorf("write fleet client certificate: %w", err)
		}
	}

	if opt.UpdateTLSServerCertificate != "" {
		if err := writeUpdateServerCertificate(opt, orbitRoot); err != nil {
			return "", fmt.Errorf("write update server certificate: %w", err)
		}
	}

	if opt.UpdateTLSClientCertificate != "" {
		if err := writeUpdateClientCertificate(opt, orbitRoot); err != nil {
			return "", fmt.Errorf("write update client certificate: %w", err)
		}
	}

	// Pick up all file contents

	contents := files.Contents{
		&files.Content{
			Source:      filepath.Join(rootDir, "**"),
			Destination: "/",
		},
		// Symlink current into /opt/orbit/bin/orbit/orbit
		&files.Content{
			Source:      "/opt/orbit/bin/orbit/" + updateOpt.Targets[constant.OrbitTUFTargetName].Platform + "/" + opt.OrbitChannel + "/orbit",
			Destination: "/opt/orbit/bin/orbit/orbit",
			Type:        "symlink",
			FileInfo: &files.ContentFileInfo{
				Mode: constant.DefaultExecutableMode | os.ModeSymlink,
			},
		},
		// Symlink current into /usr/local/bin
		&files.Content{
			Source:      "/opt/orbit/bin/orbit/orbit",
			Destination: "/usr/local/bin/orbit",
			Type:        "symlink",
			FileInfo: &files.ContentFileInfo{
				Mode: constant.DefaultExecutableMode | os.ModeSymlink,
			},
		},
	}

	// Add empty folders to be created.
	for _, emptyFolder := range []string{"/var/log/osquery", "/var/log/orbit"} {
		contents = append(contents, (&files.Content{
			Destination: emptyFolder,
			Type:        "dir",
		}).WithFileInfoDefaults())
	}

	if varLibSymlink {
		contents = append(contents,
			// Symlink needed to support old versions of orbit.
			&files.Content{
				Source:      "/opt/orbit",
				Destination: "/var/lib/orbit",
				Type:        "symlink",
				FileInfo: &files.ContentFileInfo{
					Mode: os.ModeSymlink,
				},
			})
	}

	contents, err = files.ExpandContentGlobs(contents, false)
	if err != nil {
		return "", fmt.Errorf("glob contents: %w", err)
	}
	for _, c := range contents {
		log.Debug().Interface("file", c).Msg("added file")
	}

	rpmInfo := nfpm.RPM{}
	if _, ok := pkger.(*rpm.RPM); ok {
		rpmInfo.Scripts.PostTrans = postTransPath
	}

	archLinuxInfo := nfpm.ArchLinux{}
	if _, ok := pkger.(arch.ArchLinux); ok {
		archLinuxInfo.Packager = "Fleet"
		preUpgradePath := filepath.Join(tmpDir, "preupgrade.sh")
		if err := writeArchLinuxPreUpgrade(preUpgradePath); err != nil {
			return "", fmt.Errorf("write preupgrade script: %w", err)
		}
		archLinuxInfo.Scripts.PreUpgrade = preUpgradePath
		archLinuxInfo.Scripts.PostUpgrade = postInstallPath
	}

	// Build package
	info := &nfpm.Info{
		Name:        "fleet-osquery",
		Version:     opt.Version,
		Description: "Fleet osquery -- runtime and autoupdater",
		Arch:        opt.Architecture,
		Maintainer:  "Fleet Device Management",
		Vendor:      "Fleet Device Management",
		License:     "https://github.com/fleetdm/fleet/blob/main/LICENSE",
		Homepage:    "https://fleetdm.com",
		Overridables: nfpm.Overridables{
			Contents: contents,
			Scripts: nfpm.Scripts{
				PostInstall: postInstallPath,
				PreRemove:   preRemovePath,
				PostRemove:  postRemovePath,
			},
			RPM:       rpmInfo,
			ArchLinux: archLinuxInfo,
		},
	}
	filename := pkger.ConventionalFileName(info)
	if opt.CustomOutfile != "" {
		filename = opt.CustomOutfile
	}
	if opt.NativeTooling {
		filename = filepath.Join("build", filename)
	}

	if err := os.Remove(filename); err != nil && !errors.Is(err, os.ErrNotExist) {
		return "", fmt.Errorf("removing existing file: %w", err)
	}

	if opt.NativeTooling {
		if err := secure.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
			return "", fmt.Errorf("cannot create build dir: %w", err)
		}
	}

	out, err := secure.OpenFile(filename, os.O_CREATE|os.O_RDWR, constant.DefaultFileMode)
	if err != nil {
		return "", fmt.Errorf("open output file: %w", err)
	}
	defer out.Close()

	if err := pkger.Package(info, out); err != nil {
		return "", fmt.Errorf("write package: %w", err)
	}
	if err := out.Sync(); err != nil {
		return "", fmt.Errorf("sync output file: %w", err)
	}
	log.Info().Str("path", filename).Msg("wrote package")

	return filename, nil
}

func writeSystemdUnit(opt Options, rootPath string) error {
	systemdRoot := filepath.Join(rootPath, "usr", "lib", "systemd", "system")
	if err := secure.MkdirAll(systemdRoot, constant.DefaultDirMode); err != nil {
		return fmt.Errorf("create systemd dir: %w", err)
	}
	if err := os.WriteFile(
		filepath.Join(systemdRoot, "orbit.service"),
		[]byte(`
[Unit]
Description=Orbit osquery
After=network.service syslog.service
StartLimitIntervalSec=0

[Service]
TimeoutStartSec=0
EnvironmentFile=/etc/default/orbit
ExecStart=/opt/orbit/bin/orbit/orbit
Restart=always
RestartSec=1
KillMode=control-group
KillSignal=SIGTERM
CPUQuota=20%

[Install]
WantedBy=multi-user.target
`),
		constant.DefaultSystemdUnitMode,
	); err != nil {
		return fmt.Errorf("write file: %w", err)
	}

	return nil
}

var envTemplate = template.Must(template.New("env").Parse(`
ORBIT_UPDATE_URL={{ .UpdateURL }}
ORBIT_ORBIT_CHANNEL={{ .OrbitChannel }}
ORBIT_OSQUERYD_CHANNEL={{ .OsquerydChannel }}
ORBIT_UPDATE_INTERVAL={{ .OrbitUpdateInterval }}
{{ if .Desktop }}
ORBIT_FLEET_DESKTOP=true
ORBIT_DESKTOP_CHANNEL={{ .DesktopChannel }}
{{ if .FleetDesktopAlternativeBrowserHost }}
ORBIT_FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST={{ .FleetDesktopAlternativeBrowserHost }}
{{ end }}
{{ end }}
{{ if .Insecure }}ORBIT_INSECURE=true{{ end }}
{{ if .DisableUpdates }}ORBIT_DISABLE_UPDATES=true{{ end }}
{{ if .FleetURL }}ORBIT_FLEET_URL={{.FleetURL}}{{ end }}
{{ if .FleetCertificate }}ORBIT_FLEET_CERTIFICATE=/opt/orbit/fleet.pem{{ end }}
{{ if .UpdateTLSServerCertificate }}ORBIT_UPDATE_TLS_CERTIFICATE=/opt/orbit/update.pem{{ end }}
{{ if .EnrollSecret }}ORBIT_ENROLL_SECRET={{.EnrollSecret}}{{ end }}
{{ if .Debug }}ORBIT_DEBUG=true{{ end }}
{{ if .EnableScripts }}ORBIT_ENABLE_SCRIPTS=true{{ end }}
{{ if and (ne .HostIdentifier "") (ne .HostIdentifier "uuid") }}ORBIT_HOST_IDENTIFIER={{.HostIdentifier}}{{ end }}
{{ if .OsqueryDB }}ORBIT_OSQUERY_DB={{.OsqueryDB}}{{ end }}
{{ if .EndUserEmail }}ORBIT_END_USER_EMAIL={{.EndUserEmail}}{{ end }}
{{ if .FleetManagedHostIdentityCertificate }}ORBIT_FLEET_MANAGED_HOST_IDENTITY_CERTIFICATE=true{{ end }}
{{ if .DisableSetupExperience }}ORBIT_DISABLE_SETUP_EXPERIENCE=true{{ end }}
`))

func writeEnvFile(opt Options, rootPath string) error {
	envRoot := filepath.Join(rootPath, "etc", "default")
	if err := secure.MkdirAll(envRoot, constant.DefaultDirMode); err != nil {
		return fmt.Errorf("create env dir: %w", err)
	}

	var contents bytes.Buffer
	if err := envTemplate.Execute(&contents, opt); err != nil {
		return fmt.Errorf("execute template: %w", err)
	}

	if err := os.WriteFile(
		filepath.Join(envRoot, "orbit"),
		contents.Bytes(),
		constant.DefaultFileMode,
	); err != nil {
		return fmt.Errorf("write file: %w", err)
	}

	return nil
}

var postInstallTemplate = template.Must(template.New("postinstall").Parse(`#!/bin/sh

# Exit on error
set -e

# If we have a systemd, daemon-reload away now
if command -v systemctl >/dev/null 2>&1; then
  systemctl daemon-reload >/dev/null 2>&1
{{ if .StartService -}}
  ` + postInstallSafeRestart + `
  systemctl enable orbit.service 2>&1
{{- end}}
fi
`))

func writePostInstall(opt Options, path string) error {
	var contents bytes.Buffer
	if err := postInstallTemplate.Execute(&contents, opt); err != nil {
		return fmt.Errorf("execute template: %w", err)
	}

	if err := os.WriteFile(path, contents.Bytes(), constant.DefaultFileMode); err != nil {
		return fmt.Errorf("write file: %w", err)
	}

	return nil
}

func writePreRemove(pkger nfpm.Packager, path string) error {
	// We add `|| true` in case the service is not running
	// or has been manually disabled already. Otherwise,
	// uninstallation fails.
	//
	// Upgrades require special considerations.
	//
	// On Debian systems, the old package is removed BEFORE the
	// new package is installed. The old package's prerm script is
	// called with the first argument set to "upgrade" if the
	// package is being upgraded. In this case, we do not stop or
	// disable the service; it will be restarted by the post-install
	// script. If called with "remove" or "deconfigure", we should
	// stop and disable any running orbit service.
	// https://www.debian.org/doc/debian-policy/ch-maintainerscripts.html#details-of-unpack-phase-of-installation-or-upgrade
	//
	// On RPM systems, the old package is removed AFTER the new
	// package is installed. The preun script is called with the
	// argument "0" upon uninstall and "1" upon upgrade.
	// https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/#_syntax
	// https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/#ordering
	//
	// "pkill fleet-desktop" is required because the application
	// runs as user (separate from sudo command that launched it),
	// so on some systems it's not killed properly.
	preRemoveScript := `#!/bin/sh

case "${1:-}" in
  1|remove|deconfigure)
    systemctl disable --now orbit.service || true
    pkill fleet-desktop || true
    ;;
  0|upgrade)
    ;;
esac

`
	// pre_remove script on Arch Linux receives the old version as argument.
	_, isArchLinux := pkger.(arch.ArchLinux)
	if isArchLinux {
		preRemoveScript = `#!/bin/sh

systemctl disable --now orbit.service || true
pkill fleet-desktop || true

`
	}
	if err := os.WriteFile(path, []byte(preRemoveScript), constant.DefaultFileMode); err != nil {
		return fmt.Errorf("write file: %w", err)
	}

	return nil
}

func writePostRemove(path string) error {
	if err := os.WriteFile(path, []byte(`#!/bin/sh

# For RPM during uninstall, $1 is 0
# For Debian during remove, $1 is "remove"
if [ "$1" = 0 ] || [ "$1" = "remove" ]; then
	rm -rf /var/lib/orbit /var/log/orbit /usr/local/bin/orbit /etc/default/orbit /usr/lib/systemd/system/orbit.service /opt/orbit
fi
`), constant.DefaultFileMode); err != nil {
		return fmt.Errorf("write file: %w", err)
	}

	return nil
}

// postTransTemplate contains the template for RPM posttrans scriptlet (used when upgrading).
// See https://docs.fedoraproject.org/en-US/packaging-guidelines/Scriptlets/.
//
// We cannot rely on "$1" because it's always "0" for RPM < 4.12
// (see https://github.com/rpm-software-management/rpm/commit/ab069ec876639d46d12dd76dad54fd8fb762e43d)
// thus we check if orbit service is enabled, and if not we enable it (because posttrans
// will run both on "install" and "upgrade").
var postTransTemplate = template.Must(template.New("posttrans").Parse(`#!/bin/sh

# Exit on error
set -e

if ! systemctl is-enabled orbit >/dev/null 2>&1; then
	# If we have a systemd, daemon-reload away now
	if command -v systemctl >/dev/null 2>&1; then
		systemctl daemon-reload >/dev/null 2>&1
{{ if .StartService -}}
		` + postInstallSafeRestart + `
		systemctl enable orbit.service 2>&1
{{- end}}
	fi
fi
`))

// writeRPMPostTrans sets the posttrans scriptlets necessary to support RPM upgrades.
func writeRPMPostTrans(opt Options, path string) error {
	var contents bytes.Buffer
	if err := postTransTemplate.Execute(&contents, opt); err != nil {
		return fmt.Errorf("execute template: %w", err)
	}
	if err := os.WriteFile(path, contents.Bytes(), constant.DefaultFileMode); err != nil {
		return fmt.Errorf("write file: %w", err)
	}
	return nil
}

func writeArchLinuxPreUpgrade(path string) error {
	// Currently the pre-upgrade script for ArchLinux just cleanly
	// brings the service down.
	//
	// We add `|| true` in case the service is not running
	// or has been manually disabled already. Otherwise,
	// script might return non-zero exit code.
	const preUpgradeScript = `#!/bin/sh

systemctl disable --now orbit.service || true
pkill fleet-desktop || true

`
	if err := os.WriteFile(path, []byte(preUpgradeScript), constant.DefaultFileMode); err != nil {
		return fmt.Errorf("write file: %w", err)
	}

	return nil
}
